atl-fetch 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,18 @@ Atlassian Cloud(Jira / Confluence)から情報を取得する Node.js CLI
25
25
  - Node.js 22.0.0 以上
26
26
  - pnpm(推奨)または npm
27
27
 
28
- ## インストール
28
+ ## 使い方
29
+
30
+ ### npx で即時実行(インストール不要)
31
+
32
+ ```bash
33
+ # 環境変数を設定して実行
34
+ ATLASSIAN_EMAIL="your-email@example.com" \
35
+ ATLASSIAN_API_TOKEN="your-api-token" \
36
+ npx atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123
37
+ ```
38
+
39
+ ### グローバルインストール
29
40
 
30
41
  ```bash
31
42
  # npm
@@ -50,21 +61,23 @@ API トークンは [Atlassian Account Settings](https://id.atlassian.com/manage
50
61
 
51
62
  ```bash
52
63
  # Jira Issue を取得
53
- atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123
64
+ npx atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123
54
65
 
55
66
  # Confluence ページを取得
56
- atl-fetch https://your-domain.atlassian.net/wiki/spaces/SPACE/pages/123456/Page+Title
67
+ npx atl-fetch https://your-domain.atlassian.net/wiki/spaces/SPACE/pages/123456/Page+Title
57
68
 
58
69
  # Markdown 形式で出力
59
- atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 --format markdown
70
+ npx atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 --format markdown
60
71
 
61
72
  # 添付ファイルをダウンロード
62
- atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 --download --dir ./output
73
+ npx atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 --download --dir ./output
63
74
 
64
75
  # ファイルに保存(リダイレクト)
65
- atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 > result.json
76
+ npx atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 > result.json
66
77
  ```
67
78
 
79
+ **Note**: グローバルインストール済みの場合は `npx` を省略して `atl-fetch` として実行できます。
80
+
68
81
  ## CLI オプション
69
82
 
70
83
  | オプション | 短縮形 | 説明 | デフォルト |
@@ -88,7 +101,7 @@ atl-fetch https://your-domain.atlassian.net/browse/PROJECT-123 > result.json
88
101
  認証情報の設定状態を確認します。
89
102
 
90
103
  ```bash
91
- atl-fetch auth check
104
+ npx atl-fetch auth check
92
105
  ```
93
106
 
94
107
  出力例(設定済み):
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ HTML 変換
3
+ *
4
+ * Markdown 変換の中間形式として HTML を生成する
5
+ */
6
+ import type { AdfMark, AdfNode, AttachmentPathMapping } from './types.js';
7
+ /**
8
+ * HTML 特殊文字をエスケープする
9
+ *
10
+ * @param text エスケープ対象の文字列
11
+ * @returns エスケープ済み文字列
12
+ */
13
+ export declare const escapeHtml: (text: string) => string;
14
+ /**
15
+ * ADF マークを HTML タグで囲む
16
+ *
17
+ * @param text 対象のテキスト
18
+ * @param marks 適用するマーク配列
19
+ * @returns マークを適用した HTML
20
+ */
21
+ export declare const applyMarksToHtml: (text: string, marks: readonly AdfMark[]) => string;
22
+ /**
23
+ * ADF ノードを HTML に変換する
24
+ *
25
+ * @param node ADF ノード
26
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
27
+ * @returns HTML 文字列
28
+ */
29
+ export declare const convertAdfNodeToHtml: (node: AdfNode, attachmentPaths?: AttachmentPathMapping) => string;
30
+ /**
31
+ * ADF ドキュメントを HTML に変換する
32
+ *
33
+ * @param content ADF ドキュメントのコンテンツ配列
34
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
35
+ * @returns HTML 文字列
36
+ */
37
+ export declare const convertAdfContentToHtml: (content: readonly AdfNode[] | undefined, attachmentPaths?: AttachmentPathMapping) => string;
@@ -0,0 +1,476 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ HTML 変換
3
+ *
4
+ * Markdown 変換の中間形式として HTML を生成する
5
+ */
6
+ /**
7
+ * HTML 特殊文字をエスケープする
8
+ *
9
+ * @param text エスケープ対象の文字列
10
+ * @returns エスケープ済み文字列
11
+ */
12
+ export const escapeHtml = (text) => {
13
+ return text
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
19
+ };
20
+ /**
21
+ * ADF マークを HTML タグで囲む
22
+ *
23
+ * @param text 対象のテキスト
24
+ * @param marks 適用するマーク配列
25
+ * @returns マークを適用した HTML
26
+ */
27
+ export const applyMarksToHtml = (text, marks) => {
28
+ let result = text;
29
+ for (const mark of marks) {
30
+ switch (mark.type) {
31
+ case 'strong':
32
+ result = `<strong>${result}</strong>`;
33
+ break;
34
+ case 'em':
35
+ result = `<em>${result}</em>`;
36
+ break;
37
+ case 'code':
38
+ result = `<code>${result}</code>`;
39
+ break;
40
+ case 'strike':
41
+ result = `<s>${result}</s>`;
42
+ break;
43
+ case 'underline':
44
+ result = `<u>${result}</u>`;
45
+ break;
46
+ case 'link': {
47
+ const href = mark.attrs?.['href'];
48
+ if (typeof href === 'string') {
49
+ result = `<a href="${escapeHtml(href)}">${result}</a>`;
50
+ }
51
+ break;
52
+ }
53
+ case 'textColor': {
54
+ const color = mark.attrs?.['color'];
55
+ if (typeof color === 'string') {
56
+ result = `<span style="color: ${escapeHtml(color)}">${result}</span>`;
57
+ }
58
+ break;
59
+ }
60
+ case 'subsup': {
61
+ const subType = mark.attrs?.['type'];
62
+ if (subType === 'sub') {
63
+ result = `<sub>${result}</sub>`;
64
+ }
65
+ else if (subType === 'sup') {
66
+ result = `<sup>${result}</sup>`;
67
+ }
68
+ break;
69
+ }
70
+ case 'backgroundColor': {
71
+ const bgColor = mark.attrs?.['color'];
72
+ if (typeof bgColor === 'string') {
73
+ result = `<span style="background-color: ${escapeHtml(bgColor)}">${result}</span>`;
74
+ }
75
+ break;
76
+ }
77
+ // 未知のマークタイプは無視
78
+ default:
79
+ break;
80
+ }
81
+ }
82
+ return result;
83
+ };
84
+ /**
85
+ * ADF ノードを HTML に変換する
86
+ *
87
+ * @param node ADF ノード
88
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
89
+ * @returns HTML 文字列
90
+ */
91
+ export const convertAdfNodeToHtml = (node, attachmentPaths) => {
92
+ // テキストノードの場合
93
+ if (node.type === 'text' && node.text !== undefined) {
94
+ const escapedText = escapeHtml(node.text);
95
+ if (node.marks !== undefined && node.marks.length > 0) {
96
+ return applyMarksToHtml(escapedText, node.marks);
97
+ }
98
+ return escapedText;
99
+ }
100
+ // hardBreak の場合
101
+ if (node.type === 'hardBreak') {
102
+ return '<br>';
103
+ }
104
+ // rule(水平線)の場合
105
+ if (node.type === 'rule') {
106
+ return '<hr>';
107
+ }
108
+ // メンションの場合
109
+ if (node.type === 'mention' && node.attrs !== undefined) {
110
+ const text = node.attrs['text'];
111
+ if (typeof text === 'string') {
112
+ return escapeHtml(text);
113
+ }
114
+ return '@ユーザー';
115
+ }
116
+ // 絵文字の場合
117
+ if (node.type === 'emoji' && node.attrs !== undefined) {
118
+ const text = node.attrs['text'];
119
+ const shortName = node.attrs['shortName'];
120
+ if (typeof text === 'string') {
121
+ return text;
122
+ }
123
+ if (typeof shortName === 'string') {
124
+ return shortName;
125
+ }
126
+ return '';
127
+ }
128
+ // date ノードの場合
129
+ if (node.type === 'date' && node.attrs?.['timestamp'] !== undefined) {
130
+ const timestamp = node.attrs['timestamp'];
131
+ const parsed = Number.parseInt(String(timestamp), 10);
132
+ if (Number.isNaN(parsed)) {
133
+ return '';
134
+ }
135
+ const date = new Date(parsed);
136
+ const formatted = date.toISOString().split('T')[0];
137
+ return `<time datetime="${formatted}">${formatted}</time>`;
138
+ }
139
+ // status ノードの場合
140
+ if (node.type === 'status' && node.attrs !== undefined) {
141
+ const color = node.attrs['color'];
142
+ const text = node.attrs['text'];
143
+ const colorEmojiMap = {
144
+ blue: '🔵',
145
+ green: '🟢',
146
+ neutral: '⚪',
147
+ purple: '🟣',
148
+ red: '🔴',
149
+ yellow: '🟡',
150
+ };
151
+ const emoji = typeof color === 'string' && colorEmojiMap[color] !== undefined ? colorEmojiMap[color] : '⚪';
152
+ const statusText = typeof text === 'string' ? text : '';
153
+ return `[${emoji} ${statusText}]`;
154
+ }
155
+ // media ノードの場合(添付ファイル)
156
+ if (node.type === 'media' && node.attrs !== undefined) {
157
+ const mediaId = node.attrs['id'];
158
+ const mediaType = node.attrs['type'];
159
+ // border マークのチェック
160
+ const borderMark = node.marks?.find((m) => m.type === 'border');
161
+ let borderStyle = '';
162
+ if (borderMark?.attrs !== undefined) {
163
+ const borderSize = typeof borderMark.attrs['size'] === 'number' ? borderMark.attrs['size'] : 1;
164
+ const borderColor = typeof borderMark.attrs['color'] === 'string' ? borderMark.attrs['color'] : '#000000';
165
+ borderStyle = ` style="border: ${borderSize}px solid ${escapeHtml(borderColor)}"`;
166
+ }
167
+ if (typeof mediaId === 'string' && attachmentPaths?.[mediaId] !== undefined) {
168
+ const localPath = attachmentPaths[mediaId];
169
+ const alt = typeof node.attrs['alt'] === 'string' ? node.attrs['alt'] : mediaId;
170
+ return `<img src="${escapeHtml(localPath)}" alt="${escapeHtml(alt)}"${borderStyle}>`;
171
+ }
172
+ // 外部リンクの場合
173
+ if (mediaType === 'external' || mediaType === 'link') {
174
+ const url = node.attrs['url'];
175
+ if (typeof url === 'string') {
176
+ return `<img src="${escapeHtml(url)}" alt=""${borderStyle}>`;
177
+ }
178
+ }
179
+ // マッピングがない場合はプレースホルダー
180
+ return '[添付ファイル]';
181
+ }
182
+ // mediaInline ノードの場合(インラインメディア)
183
+ if (node.type === 'mediaInline' && node.attrs !== undefined) {
184
+ const mediaId = node.attrs['id'];
185
+ if (typeof mediaId === 'string' && attachmentPaths?.[mediaId] !== undefined) {
186
+ const localPath = attachmentPaths[mediaId];
187
+ const alt = typeof node.attrs['alt'] === 'string' ? node.attrs['alt'] : mediaId;
188
+ return `<img src="${escapeHtml(localPath)}" alt="${escapeHtml(alt)}">`;
189
+ }
190
+ // マッピングがない場合はプレースホルダー
191
+ return '[添付ファイル]';
192
+ }
193
+ // mediaSingle(メディアコンテナ)の場合
194
+ if (node.type === 'mediaSingle' && node.content !== undefined) {
195
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
196
+ }
197
+ // mediaGroup の場合
198
+ if (node.type === 'mediaGroup' && node.content !== undefined) {
199
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
200
+ }
201
+ // inlineCard(インラインリンク)の場合
202
+ if (node.type === 'inlineCard' && node.attrs !== undefined) {
203
+ const url = node.attrs['url'];
204
+ if (typeof url === 'string') {
205
+ return `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`;
206
+ }
207
+ return '';
208
+ }
209
+ // blockCard(ブロックリンク)の場合
210
+ if (node.type === 'blockCard' && node.attrs !== undefined) {
211
+ const url = node.attrs['url'];
212
+ if (typeof url === 'string') {
213
+ return `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>`;
214
+ }
215
+ return '';
216
+ }
217
+ // 子ノードがある場合
218
+ if (node.content !== undefined && Array.isArray(node.content)) {
219
+ const childrenHtml = node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
220
+ switch (node.type) {
221
+ case 'doc':
222
+ return childrenHtml;
223
+ case 'paragraph':
224
+ return `<p>${childrenHtml}</p>`;
225
+ case 'heading': {
226
+ const level = typeof node.attrs?.['level'] === 'number' ? node.attrs['level'] : 1;
227
+ const safeLevel = Math.max(1, Math.min(6, level));
228
+ return `<h${safeLevel}>${childrenHtml}</h${safeLevel}>`;
229
+ }
230
+ case 'bulletList':
231
+ return `<ul>${childrenHtml}</ul>`;
232
+ case 'orderedList':
233
+ return `<ol>${childrenHtml}</ol>`;
234
+ case 'listItem':
235
+ return `<li>${childrenHtml}</li>`;
236
+ case 'blockquote':
237
+ return `<blockquote>${childrenHtml}</blockquote>`;
238
+ case 'codeBlock': {
239
+ const language = typeof node.attrs?.['language'] === 'string' ? node.attrs['language'] : '';
240
+ const langClass = language ? ` class="language-${escapeHtml(language)}"` : '';
241
+ // コードブロック内のテキストは子ノードから取得
242
+ const codeText = node.content
243
+ .map((child) => (child.type === 'text' && child.text !== undefined ? child.text : ''))
244
+ .join('');
245
+ return `<pre><code${langClass}>${escapeHtml(codeText)}</code></pre>`;
246
+ }
247
+ case 'table':
248
+ return `<table>${childrenHtml}</table>`;
249
+ case 'tableRow':
250
+ return `<tr>${childrenHtml}</tr>`;
251
+ case 'tableHeader':
252
+ return `<th>${childrenHtml}</th>`;
253
+ case 'tableCell':
254
+ return `<td>${childrenHtml}</td>`;
255
+ case 'panel': {
256
+ // panel タイプを GitHub Alerts 形式に変換
257
+ const panelType = typeof node.attrs?.['panelType'] === 'string' ? node.attrs['panelType'] : 'info';
258
+ const alertTypeMap = {
259
+ error: 'WARNING',
260
+ info: 'NOTE',
261
+ note: 'NOTE',
262
+ success: 'TIP',
263
+ warning: 'WARNING',
264
+ };
265
+ const alertType = alertTypeMap[panelType] || 'NOTE';
266
+ return `<blockquote data-github-alert="${alertType}">${childrenHtml}</blockquote>`;
267
+ }
268
+ case 'expand':
269
+ case 'nestedExpand': {
270
+ const expandTitle = typeof node.attrs?.['title'] === 'string' ? node.attrs['title'] : '展開';
271
+ return `<details><summary>${escapeHtml(expandTitle)}</summary>${childrenHtml}</details>`;
272
+ }
273
+ // 未知のノードタイプは子ノードの内容を返す
274
+ default:
275
+ return childrenHtml;
276
+ }
277
+ }
278
+ // 子ノードもテキストもない場合は空文字列
279
+ return '';
280
+ };
281
+ /**
282
+ * ADF ドキュメントを HTML に変換する
283
+ *
284
+ * @param content ADF ドキュメントのコンテンツ配列
285
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
286
+ * @returns HTML 文字列
287
+ */
288
+ export const convertAdfContentToHtml = (content, attachmentPaths) => {
289
+ if (content === undefined) {
290
+ return '';
291
+ }
292
+ return content.map((node) => convertAdfNodeToHtml(node, attachmentPaths)).join('');
293
+ };
294
+ // ============================================================
295
+ // In-source Testing(プライベート関数のテスト)
296
+ // ============================================================
297
+ if (import.meta.vitest) {
298
+ const { describe, expect, it } = import.meta.vitest;
299
+ describe('escapeHtml', () => {
300
+ it('Given: 特殊文字を含む文字列, When: escapeHtml を呼び出す, Then: エスケープされる', () => {
301
+ expect(escapeHtml('&')).toBe('&amp;');
302
+ expect(escapeHtml('<')).toBe('&lt;');
303
+ expect(escapeHtml('>')).toBe('&gt;');
304
+ expect(escapeHtml('"')).toBe('&quot;');
305
+ expect(escapeHtml("'")).toBe('&#39;');
306
+ });
307
+ it('Given: 複数の特殊文字, When: escapeHtml を呼び出す, Then: すべてエスケープされる', () => {
308
+ expect(escapeHtml('<script>alert("XSS")</script>')).toBe('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
309
+ });
310
+ });
311
+ describe('applyMarksToHtml', () => {
312
+ it('Given: strong マーク, When: applyMarksToHtml を呼び出す, Then: <strong> タグで囲まれる', () => {
313
+ const result = applyMarksToHtml('テキスト', [{ type: 'strong' }]);
314
+ expect(result).toBe('<strong>テキスト</strong>');
315
+ });
316
+ it('Given: em マーク, When: applyMarksToHtml を呼び出す, Then: <em> タグで囲まれる', () => {
317
+ const result = applyMarksToHtml('テキスト', [{ type: 'em' }]);
318
+ expect(result).toBe('<em>テキスト</em>');
319
+ });
320
+ it('Given: code マーク, When: applyMarksToHtml を呼び出す, Then: <code> タグで囲まれる', () => {
321
+ const result = applyMarksToHtml('code', [{ type: 'code' }]);
322
+ expect(result).toBe('<code>code</code>');
323
+ });
324
+ it('Given: strike マーク, When: applyMarksToHtml を呼び出す, Then: <s> タグで囲まれる', () => {
325
+ const result = applyMarksToHtml('テキスト', [{ type: 'strike' }]);
326
+ expect(result).toBe('<s>テキスト</s>');
327
+ });
328
+ it('Given: underline マーク, When: applyMarksToHtml を呼び出す, Then: <u> タグで囲まれる', () => {
329
+ const result = applyMarksToHtml('テキスト', [{ type: 'underline' }]);
330
+ expect(result).toBe('<u>テキスト</u>');
331
+ });
332
+ it('Given: link マーク, When: applyMarksToHtml を呼び出す, Then: <a> タグで囲まれる', () => {
333
+ const result = applyMarksToHtml('リンク', [{ attrs: { href: 'https://example.com' }, type: 'link' }]);
334
+ expect(result).toBe('<a href="https://example.com">リンク</a>');
335
+ });
336
+ it('Given: textColor マーク, When: applyMarksToHtml を呼び出す, Then: <span> タグで色が適用される', () => {
337
+ const result = applyMarksToHtml('テキスト', [{ attrs: { color: '#ff0000' }, type: 'textColor' }]);
338
+ expect(result).toBe('<span style="color: #ff0000">テキスト</span>');
339
+ });
340
+ it('Given: subsup マーク(sub), When: applyMarksToHtml を呼び出す, Then: <sub> タグで囲まれる', () => {
341
+ const result = applyMarksToHtml('2', [{ attrs: { type: 'sub' }, type: 'subsup' }]);
342
+ expect(result).toBe('<sub>2</sub>');
343
+ });
344
+ it('Given: subsup マーク(sup), When: applyMarksToHtml を呼び出す, Then: <sup> タグで囲まれる', () => {
345
+ const result = applyMarksToHtml('2', [{ attrs: { type: 'sup' }, type: 'subsup' }]);
346
+ expect(result).toBe('<sup>2</sup>');
347
+ });
348
+ it('Given: backgroundColor マーク, When: applyMarksToHtml を呼び出す, Then: 背景色が適用される', () => {
349
+ const result = applyMarksToHtml('テキスト', [{ attrs: { color: '#ffff00' }, type: 'backgroundColor' }]);
350
+ expect(result).toBe('<span style="background-color: #ffff00">テキスト</span>');
351
+ });
352
+ it('Given: 複数のマーク, When: applyMarksToHtml を呼び出す, Then: すべてのタグが適用される', () => {
353
+ const result = applyMarksToHtml('テキスト', [{ type: 'strong' }, { type: 'em' }]);
354
+ expect(result).toBe('<em><strong>テキスト</strong></em>');
355
+ });
356
+ it('Given: 未知のマーク, When: applyMarksToHtml を呼び出す, Then: 無視される', () => {
357
+ const result = applyMarksToHtml('テキスト', [{ type: 'unknown' }]);
358
+ expect(result).toBe('テキスト');
359
+ });
360
+ });
361
+ describe('convertAdfNodeToHtml', () => {
362
+ it('Given: テキストノード, When: convertAdfNodeToHtml を呼び出す, Then: エスケープされたテキストが返される', () => {
363
+ const node = { text: '<script>', type: 'text' };
364
+ expect(convertAdfNodeToHtml(node)).toBe('&lt;script&gt;');
365
+ });
366
+ it('Given: hardBreak, When: convertAdfNodeToHtml を呼び出す, Then: <br> が返される', () => {
367
+ const node = { type: 'hardBreak' };
368
+ expect(convertAdfNodeToHtml(node)).toBe('<br>');
369
+ });
370
+ it('Given: rule, When: convertAdfNodeToHtml を呼び出す, Then: <hr> が返される', () => {
371
+ const node = { type: 'rule' };
372
+ expect(convertAdfNodeToHtml(node)).toBe('<hr>');
373
+ });
374
+ it('Given: mention, When: convertAdfNodeToHtml を呼び出す, Then: メンションテキストが返される', () => {
375
+ const node = { attrs: { text: '@田中' }, type: 'mention' };
376
+ expect(convertAdfNodeToHtml(node)).toBe('@田中');
377
+ });
378
+ it('Given: emoji with text, When: convertAdfNodeToHtml を呼び出す, Then: 絵文字テキストが返される', () => {
379
+ const node = { attrs: { text: '😀' }, type: 'emoji' };
380
+ expect(convertAdfNodeToHtml(node)).toBe('😀');
381
+ });
382
+ it('Given: date, When: convertAdfNodeToHtml を呼び出す, Then: <time> タグが返される', () => {
383
+ const node = { attrs: { timestamp: 1609459200000 }, type: 'date' };
384
+ expect(convertAdfNodeToHtml(node)).toBe('<time datetime="2021-01-01">2021-01-01</time>');
385
+ });
386
+ it('Given: status, When: convertAdfNodeToHtml を呼び出す, Then: ステータスバッジが返される', () => {
387
+ const node = { attrs: { color: 'green', text: '完了' }, type: 'status' };
388
+ expect(convertAdfNodeToHtml(node)).toBe('[🟢 完了]');
389
+ });
390
+ it('Given: media with attachmentPaths, When: convertAdfNodeToHtml を呼び出す, Then: <img> タグが返される', () => {
391
+ const node = { attrs: { id: 'file-123' }, type: 'media' };
392
+ const paths = { 'file-123': '/attachments/image.png' };
393
+ expect(convertAdfNodeToHtml(node, paths)).toBe('<img src="/attachments/image.png" alt="file-123">');
394
+ });
395
+ it('Given: media without mapping, When: convertAdfNodeToHtml を呼び出す, Then: プレースホルダーが返される', () => {
396
+ const node = { attrs: { id: 'file-123' }, type: 'media' };
397
+ expect(convertAdfNodeToHtml(node)).toBe('[添付ファイル]');
398
+ });
399
+ it('Given: inlineCard, When: convertAdfNodeToHtml を呼び出す, Then: <a> タグが返される', () => {
400
+ const node = { attrs: { url: 'https://example.com' }, type: 'inlineCard' };
401
+ expect(convertAdfNodeToHtml(node)).toBe('<a href="https://example.com">https://example.com</a>');
402
+ });
403
+ it('Given: blockCard, When: convertAdfNodeToHtml を呼び出す, Then: <p><a> タグが返される', () => {
404
+ const node = { attrs: { url: 'https://example.com' }, type: 'blockCard' };
405
+ expect(convertAdfNodeToHtml(node)).toBe('<p><a href="https://example.com">https://example.com</a></p>');
406
+ });
407
+ it('Given: paragraph, When: convertAdfNodeToHtml を呼び出す, Then: <p> タグで囲まれる', () => {
408
+ const node = { content: [{ text: 'テスト', type: 'text' }], type: 'paragraph' };
409
+ expect(convertAdfNodeToHtml(node)).toBe('<p>テスト</p>');
410
+ });
411
+ it('Given: heading level 2, When: convertAdfNodeToHtml を呼び出す, Then: <h2> タグで囲まれる', () => {
412
+ const node = { attrs: { level: 2 }, content: [{ text: '見出し', type: 'text' }], type: 'heading' };
413
+ expect(convertAdfNodeToHtml(node)).toBe('<h2>見出し</h2>');
414
+ });
415
+ it('Given: bulletList, When: convertAdfNodeToHtml を呼び出す, Then: <ul> タグで囲まれる', () => {
416
+ const node = {
417
+ content: [
418
+ { content: [{ content: [{ text: 'アイテム', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
419
+ ],
420
+ type: 'bulletList',
421
+ };
422
+ expect(convertAdfNodeToHtml(node)).toBe('<ul><li><p>アイテム</p></li></ul>');
423
+ });
424
+ it('Given: orderedList, When: convertAdfNodeToHtml を呼び出す, Then: <ol> タグで囲まれる', () => {
425
+ const node = {
426
+ content: [
427
+ { content: [{ content: [{ text: 'アイテム', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
428
+ ],
429
+ type: 'orderedList',
430
+ };
431
+ expect(convertAdfNodeToHtml(node)).toBe('<ol><li><p>アイテム</p></li></ol>');
432
+ });
433
+ it('Given: blockquote, When: convertAdfNodeToHtml を呼び出す, Then: <blockquote> タグで囲まれる', () => {
434
+ const node = { content: [{ content: [{ text: '引用', type: 'text' }], type: 'paragraph' }], type: 'blockquote' };
435
+ expect(convertAdfNodeToHtml(node)).toBe('<blockquote><p>引用</p></blockquote>');
436
+ });
437
+ it('Given: codeBlock, When: convertAdfNodeToHtml を呼び出す, Then: <pre><code> タグで囲まれる', () => {
438
+ const node = {
439
+ attrs: { language: 'typescript' },
440
+ content: [{ text: 'const x = 1;', type: 'text' }],
441
+ type: 'codeBlock',
442
+ };
443
+ expect(convertAdfNodeToHtml(node)).toBe('<pre><code class="language-typescript">const x = 1;</code></pre>');
444
+ });
445
+ it('Given: table, When: convertAdfNodeToHtml を呼び出す, Then: <table> 構造が返される', () => {
446
+ const node = {
447
+ content: [
448
+ {
449
+ content: [
450
+ { content: [{ content: [{ text: 'ヘッダー', type: 'text' }], type: 'paragraph' }], type: 'tableHeader' },
451
+ ],
452
+ type: 'tableRow',
453
+ },
454
+ ],
455
+ type: 'table',
456
+ };
457
+ expect(convertAdfNodeToHtml(node)).toBe('<table><tr><th><p>ヘッダー</p></th></tr></table>');
458
+ });
459
+ it('Given: panel, When: convertAdfNodeToHtml を呼び出す, Then: GitHub Alerts 形式の blockquote が返される', () => {
460
+ const node = {
461
+ attrs: { panelType: 'info' },
462
+ content: [{ content: [{ text: '情報', type: 'text' }], type: 'paragraph' }],
463
+ type: 'panel',
464
+ };
465
+ expect(convertAdfNodeToHtml(node)).toBe('<blockquote data-github-alert="NOTE"><p>情報</p></blockquote>');
466
+ });
467
+ it('Given: expand, When: convertAdfNodeToHtml を呼び出す, Then: <details> タグが返される', () => {
468
+ const node = {
469
+ attrs: { title: '詳細' },
470
+ content: [{ content: [{ text: '内容', type: 'text' }], type: 'paragraph' }],
471
+ type: 'expand',
472
+ };
473
+ expect(convertAdfNodeToHtml(node)).toBe('<details><summary>詳細</summary><p>内容</p></details>');
474
+ });
475
+ });
476
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ PlainText 変換
3
+ */
4
+ import type { AdfNode } from './types.js';
5
+ /**
6
+ * ADF ノードからプレーンテキストを抽出する
7
+ *
8
+ * @param node ADF ノード
9
+ * @returns 抽出されたプレーンテキスト
10
+ */
11
+ export declare const extractTextFromAdfNode: (node: AdfNode) => string;
12
+ /**
13
+ * ADF ドキュメントのトップレベルコンテンツを処理する
14
+ *
15
+ * @param content トップレベルのコンテンツ配列
16
+ * @returns プレーンテキスト
17
+ */
18
+ export declare const processAdfContent: (content: readonly AdfNode[]) => string;
19
+ /**
20
+ * ADF(Atlassian Document Format)をプレーンテキストに変換する
21
+ *
22
+ * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
23
+ * @returns プレーンテキスト
24
+ */
25
+ export declare const convertAdfToPlainText: (adf: unknown) => string;